Skip to content

feat: add project metadata to project list and view - #693

Merged
NickJosevski merged 9 commits into
mainfrom
nj/issue-491
Sep 16, 2026
Merged

NickJosevski merged 9 commits into
mainfrom
nj/issue-491

Conversation

@NickJosevski

Copy link
Copy Markdown
Contributor

Fixes #491

What changed

octopus project list and octopus project view returned far less than the REST API does. Both now carry the metadata the issue asked for.

project list

  • JSON adds Slug, SpaceId, ProjectGroupId, ProjectGroupName, LifecycleId, LifecycleName, IsDisabled, IsVersionControlled, TenantedDeploymentMode.
  • Table adds SLUG, PROJECT GROUP, LIFECYCLE.
  • Basic is unchanged (still just names — it is the format scripts pipe into xargs).

project view

  • JSON adds SpaceId, IsDisabled, ProjectGroupId/Name, LifecycleId/Name, TenantedDeploymentMode, DeploymentProcessId, VariableSetId, IncludedLibraryVariableSetIds, ClonedFromProjectId, AutoCreateRelease, DefaultGuidedFailureMode, DefaultToSkipIfAlreadyInstalled, DiscreteChannelRelease, ReleaseNotesTemplate, VersioningStrategy, ProjectConnectivityPolicy, Templates.
  • Table adds PROJECT GROUP and LIFECYCLE; basic adds project group, lifecycle, tenanted deployment mode and enabled/disabled state.

ID resolution. Group and lifecycle IDs resolve to names the way channel list does since bf27551: list makes two extra GetAll calls for the whole listing (constant, not N+1), view makes two GetByID calls. Both are best-effort — a permissions failure leaves the command working and the ID is displayed instead of the name, and the *Name JSON fields are omitted.

Backwards compatibility. Every existing JSON field keeps its name, its type, and its presence — nothing was renamed, removed, or given omitempty that did not have it. Description still appears as "" on a project with no description. Only additive changes. Table column sets did change (columns added, none removed), and basic output for view gained lines; list --format basic is byte-for-byte unchanged.

Is project view -f json really broken?

No — not any more. The issue (Feb 2025) shows project view -f json printing the human blob, and that was real at the time. It was fixed in e6a5343 "feat: support project view -f for project view (#533)" in Aug 2025, which added the Json/Table/Basic mappers. Verified with a new outputFormat json test in pkg/cmd/project/view/view_test.go. No fix needed, only enrichment.

Before / after

project list -f json

Before:

[
  { "Id": "Projects-22", "Name": "Fire Project", "Description": "", "ProjectTags": ["team/red"] }
]

After:

[
  {
    "Id": "Projects-22",
    "Name": "Fire Project",
    "Description": "",
    "ProjectTags": ["team/red"],
    "Slug": "fire-project",
    "SpaceId": "Spaces-1",
    "ProjectGroupId": "ProjectGroups-1",
    "ProjectGroupName": "Default Project Group",
    "LifecycleId": "Lifecycles-1",
    "LifecycleName": "Default Lifecycle",
    "IsDisabled": false,
    "IsVersionControlled": false,
    "TenantedDeploymentMode": "Untenanted"
  }
]

project list -f table

Before:

NAME           DESCRIPTION  TAGS
Fire Project                team/red
Water Project  Wet things   team/blue

After:

NAME           SLUG           PROJECT GROUP          LIFECYCLE          DESCRIPTION  TAGS
Fire Project   fire-project   Default Project Group  Default Lifecycle               team/red
Water Project  water-project  Default Project Group  Lifecycles-99      Wet things   team/blue

(Lifecycles-99 is the fallback: that lifecycle was not in the lookup.)

project list -f basic

Unchanged:

Fire Project
Water Project

project view -f json

Before:

{
  "Id": "Projects-22",
  "Name": "Fire Project",
  "Slug": "fire-project",
  "Description": "Fire things",
  "IsVersionControlled": false,
  "VersionControlBranch": "Not version controlled",
  "ProjectTags": ["team/red"],
  "WebUrl": "http://server/app#/Spaces-1/projects/Projects-22"
}

After:

{
  "Id": "Projects-22",
  "Name": "Fire Project",
  "Slug": "fire-project",
  "Description": "Fire things",
  "IsVersionControlled": false,
  "VersionControlBranch": "Not version controlled",
  "ProjectTags": ["team/red"],
  "WebUrl": "http://server/app#/Spaces-1/projects/Projects-22",
  "SpaceId": "Spaces-1",
  "IsDisabled": false,
  "ProjectGroupId": "ProjectGroups-1",
  "ProjectGroupName": "Default Project Group",
  "LifecycleId": "Lifecycles-1",
  "LifecycleName": "Default Lifecycle",
  "TenantedDeploymentMode": "Untenanted",
  "DeploymentProcessId": "deploymentprocess-Projects-22",
  "VariableSetId": "variableset-Projects-22",
  "IncludedLibraryVariableSetIds": ["LibraryVariableSets-1"],
  "AutoCreateRelease": false,
  "DefaultToSkipIfAlreadyInstalled": false,
  "DiscreteChannelRelease": false,
  "VersioningStrategy": {
    "Template": "#{Octopus.Version.LastMajor}.#{Octopus.Version.LastMinor}.#{Octopus.Version.NextPatch}"
  },
  "ProjectConnectivityPolicy": {
    "AllowDeploymentsToNoTargets": false,
    "ExcludeUnhealthyTargets": false
  }
}

project view -f table

Before:

NAME          SLUG          DESCRIPTION  VERSION CONTROL         TAGS      WEB URL
Fire Project  fire-project  Fire things  Not version controlled  team/red  http://server/app#/Spaces-1/projects/Projects-22

After:

NAME          SLUG          PROJECT GROUP          LIFECYCLE          DESCRIPTION  VERSION CONTROL         TAGS      WEB URL
Fire Project  fire-project  Default Project Group  Default Lifecycle  Fire things  Not version controlled  team/red  http://server/app#/Spaces-1/projects/Projects-22

project view -f basic

Before:

Fire Project (fire-project)
Version control branch: Not version controlled
Tags: team/red
Fire things
View this project in Octopus Deploy: http://server/app#/Spaces-1/projects/Projects-22

After:

Fire Project (fire-project)
Project group: Default Project Group
Lifecycle: Default Lifecycle
Tenanted deployment mode: Untenanted
Version control branch: Not version controlled
Tags: team/red
Fire things
Project is enabled
View this project in Octopus Deploy: http://server/app#/Spaces-1/projects/Projects-22

Test evidence

New pkg/cmd/project/list/list_test.go (4 cases) and pkg/cmd/project/view/view_test.go (4 cases) — neither command had any unit tests before. Both cover table, basic and strict-JSON output, plus the lookup-failure fallback path (403 on lifecycles and project groups still renders, showing IDs).

$ go build ./...
BUILD OK

$ go test ./pkg/cmd/project/...
ok  github.com/OctopusDeploy/cli/pkg/cmd/project/convert
ok  github.com/OctopusDeploy/cli/pkg/cmd/project/create
ok  github.com/OctopusDeploy/cli/pkg/cmd/project/list
ok  github.com/OctopusDeploy/cli/pkg/cmd/project/shared
ok  github.com/OctopusDeploy/cli/pkg/cmd/project/variables/create
ok  github.com/OctopusDeploy/cli/pkg/cmd/project/variables/shared
ok  github.com/OctopusDeploy/cli/pkg/cmd/project/variables/update
ok  github.com/OctopusDeploy/cli/pkg/cmd/project/view

$ go test ./pkg/...
all packages ok, no failures

Open questions / options

Draft because these are judgement calls I would rather have decided than assumed.

1. Which fields belong in the table vs JSON only?
I put slug, project group and lifecycle in the table because they are short, high-signal, and identify a project the way a name alone doesn't. list is now 6 columns, view 8 — wide, but target list is already 9, so this is within house style. I deliberately kept IsDisabled, IsVersionControlled and TenantedDeploymentMode out of the list table; they are boolean-ish and would push it toward a wall of text.
Alternative: drop DESCRIPTION from the list table — it is the one column that can be arbitrarily long and the only one that ever needs truncating. That is a removal, so I did not do it unilaterally.
Recommendation: keep as is; revisit DESCRIPTION if the table gets wider again.

2. Should IDs resolve to names, and what does it cost?
list costs two extra round trips regardless of project count (Lifecycles.GetAll + ProjectGroups.GetAll), which is what channel list and target list already do. On a space with many lifecycles those responses are not tiny, but they are two requests, not N.
Alternative: resolve only in view (2 × GetByID) and leave list showing raw IDs.
Recommendation: resolve in both. Raw Lifecycles-1 in a listing is not useful to a human, and the JSON keeps both the ID and the name so scripts lose nothing. Note both lookups are best-effort — a user without lifecycle or project-group read permission still gets a working listing, just with IDs.

3. Always-richer output, or a --full / --detail flag?
I went with always-richer. Arguments for a flag: view -f json now emits Templates and ProjectConnectivityPolicy, which can be large on a real project, and the two extra API calls become opt-in.
Arguments against: the issue is asking for the data to be available, -f json is already the "give me everything" format, and a flag that everyone has to remember to pass is a worse default. jq handles the extra keys for free.
Recommendation: no flag. If the payload size becomes a real complaint, the cleaner lever is a --fields selector across all commands rather than a project-specific --full.

4. Templates on project view.
Included because the issue explicitly lists it. It contains DefaultValue, which can hold a SensitiveValue — the API returns those with HasValue/Hint and no plaintext, so nothing secret is disclosed, but worth a second opinion from someone who knows that contract better than I do.

5. Project is enabled in basic view.
Added unconditionally to match tenant view, which always prints enabled/disabled. If the preference is to keep the common case tighter, printing the line only when the project is disabled is a one-line change.

🤖 Generated with Claude Code

Comment thread pkg/cmd/project/view/view.go Outdated
if !project.IsVersionControlled {
return "Not version controlled"
}
return project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch()

@NickJosevski NickJosevski Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

versionControlBranch can panic on an interface-conversion. The bare type assertion project.PersistenceSettings.(projects.GitPersistenceSettings) panics if the server ever returns IsVersionControlled: true without a Git-typed PersistenceSettings block — in SDK v2.114.1, Project.UnmarshalJSON leaves PersistenceSettings as a nil interface when the field is absent (and the two JSON fields are independent, so nothing guarantees they agree). This predates the PR, but the refactor consolidates it into a helper now called from all three output mappers, so it is a good moment to make it defensive:

Suggested change
return project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch()
if gitSettings, ok := project.PersistenceSettings.(projects.GitPersistenceSettings); ok {
return gitSettings.DefaultBranch()
}
return ""

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in a1d4f91, with your suggested shape (comma-ok, empty string fallback).

Confirmed it's reachable rather than theoretical: PersistenceSettings carries no validate:"required" tag, so Project.UnmarshalJSON accepts a payload with IsVersionControlled: true and no settings block, leaving the interface nil. The regression test project view does not panic when a version controlled project has no git settings (pkg/cmd/project/view/view_test.go) serves exactly that payload; I reverted versionControlBranch to the bare assertion and ran it, and it panics with interface conversion: interface is nil, not projects.GitPersistenceSettings.

One knock-on, fixed in c72af0e: the "" fallback made basic output print a bare Version control branch: line, which is the same blank-label problem you raised on the project group / lifecycle labels. That label is now skipped when the branch is empty, and the regression test asserts its absence. Table and JSON still get "" for that field — an empty cell and an empty string read fine there, and changing the JSON value to a sentinel would be a schema decision rather than part of a panic fix.

Comment thread pkg/cmd/project/list/list.go Outdated

// two lookups for the whole list rather than one per project, and best-effort
// as channel list is: listing still works without access to either
lifecycleMap := shared.GetLifecycleMap(client)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

project list -f basic now makes 3 API round trips instead of 1. GetLifecycleMap and GetProjectGroupMap are fetched unconditionally before output.PrintArray, but the Basic mapper only prints p.GetName() — for basic output (common in scripting) the two /all lookups are pure wasted latency and server load. Consider checking the resolved output format first (as PrintArray does via constants.FlagOutputFormat / viper) and skipping the lookups for basic, or lazily populating the maps on first use.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 6488338.

PrintArray and PrintResource each had their own copy of the flag-then-viper format resolution, so rather than re-implement it a third time in listRun I extracted it as output.ResolveOutputFormat(cmd) and made both printers call it. listRun now only fetches the two maps when the resolved format isn't basic, so project list -f basic is back to one round trip. Went with the up-front check over lazy population because the maps are read from inside the Table/Json mappers, and lazy fill would mean either a closure with a sync.Once or a nil-map check at each of four call sites for no real gain.

Covered by outputFormat basic lists just names, without the name lookups in pkg/cmd/project/list/list_test.go: it queues only root, space and projects/all, and because the mock server has no response waiting for a fourth request the test deadlocks (and the package times out) if the lookups come back. That's how I confirmed the assertion actually bites.

Deliberately not changed: project view still does its two single-ID lookups unconditionally, because its basic output does print the project group and lifecycle names, so there's nothing to skip there.

Comment thread pkg/cmd/project/shared/shared.go Outdated

// GetLifecycleMap resolves lifecycle IDs to names for display. Best-effort: a
// failed lookup yields an empty map and callers fall back to the ID.
func GetLifecycleMap(octopus *client.Client) map[string]string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates getLifecycleMap in pkg/cmd/channel/list/list.go (line 151) byte-for-byte. Since this PR promotes the pattern to a shared helper, consider migrating channel list to call it too (or placing the helper in a neutral package outside pkg/cmd/project/ so a channel command importing it does not read oddly). Two identical copies will drift — e.g. if pagination or error reporting ever changes.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in e3f4330, taking the second of your two options.

GetLifecycleMap, GetProjectGroupMap, GetLifecycleName, GetProjectGroupName and DisplayName moved to a new neutral pkg/lookups package, and pkg/cmd/channel/list/getLifecycleMap is deleted in favour of lookups.GetLifecycleMap. That avoids a channel command importing a project command's shared package, which was the part that read oddly. pkg/cmd/project/shared keeps TenantedDeploymentMode, since that is genuinely project specific.

No new tests — this is a move plus one call-site swap, and the behaviour is covered by the existing pkg/cmd/channel/list and pkg/cmd/project/{list,view} suites, which pass.

Worth noting for later: the shared copy inherits the channel version's blind spot rather than fixing it. Lifecycles.GetAll() / ProjectGroups.GetAll() swallow the error and return an empty map, so a 403 or a transport failure is indistinguishable from "no lifecycles exist" and the caller silently shows IDs. That's now one place to fix instead of two, which was your point, but I haven't changed the semantics in this PR.

Comment thread pkg/cmd/project/view/view.go Outdated
result.WriteString(fmt.Sprintf("%s %s\n", output.Bold(project.Name), output.Dimf("(%s)", project.Slug)))

// where the project sits and how it releases
result.WriteString(fmt.Sprintf("Project group: %s\n", output.Cyan(shared.DisplayName(project.ProjectGroupID, projectGroupName))))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: empty ID prints a blank labeled line. GetLifecycleName/GetProjectGroupName explicitly guard id == "", but when that case fires, DisplayName("", "") returns "", so basic output prints Project group: / Lifecycle: with nothing after the label (and the table shows an empty cell). If an empty ID is reachable, a placeholder like Unknown (or skipping the line) would read better.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in e74c28d — the label is skipped rather than printed with nothing after it — but with a correction to the "if an empty ID is reachable" premise: for these two fields it isn't, at least not through project view.

Project.UnmarshalJSON in the SDK validates the decoded payload with validator.Struct, and both fields are tagged validate:"required" (v2.114.1, pkg/projects/project.go: LifecycleID line 94, ProjectGroupID line 96). So a project response with an empty LifecycleId or ProjectGroupId never deserialises. I tried to write a test for it and watched Projects.GetByIdentifier swallow the unmarshal failure and fall back to GET /api/Spaces-1/projects?partialName=Projects-22 — the formatter is never reached. So the guard is pure defence in depth, and I've left it untested rather than build a fake search-fallback response to exercise dead code.

I picked skipping the line over an Unknown placeholder because the non-empty-ID case already degrades gracefully (DisplayName falls back to the ID — covered by project view falls back to IDs when the lookups fail (basic)), so the only way to reach a blank label is a payload the SDK rejects.

The blank label that is reachable is the one right below: versionControlBranch returns "" for a version-controlled project with no Git settings, which printed Version control branch: with nothing after it. Fixed the same way in c72af0e, and that one has a test.

Comment thread pkg/cmd/project/view/view.go Outdated
// footer with web URL
url := util.GenerateWebURL(opts.Host, project.SpaceID, fmt.Sprintf("projects/%s", project.GetID()))
url := webUrl(opts, project)
result.WriteString(fmt.Sprintf("View this project in Octopus Deploy: %s\n", output.Blue(url)))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing quirk this refactor keeps: browser.OpenURL lives inside the Basic formatter, so octopus project view X --web -f table or -f json silently never opens the browser — the flag only works with the default basic format. Worth hoisting the opts.flags.Web.Value check into viewRun so --web behaves the same for every output format.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 67e3cc6, in this PR rather than a follow-up — but flagging it as the one change here that's a judgement call, since you're right that the quirk is pre-existing.

Why I kept it in: the fix is the flag check moving into viewRun, and viewRun plus that exact block of the basic formatter are already rewritten by this PR (the webUrl extraction touches the same lines). Splitting it out would mean a follow-up PR whose whole diff conflicts with this one for no isolation benefit. Easy to pull back out if you'd rather keep the PR strictly to metadata — it's a single commit at the tip, so git revert 67e3cc6 here and re-land it separately is clean.

Two things to be aware of in the new behaviour:

  • --web now opens the browser before the output is written, where previously (basic only) it opened after. Nothing depends on the ordering, but it is a visible difference if you're watching a terminal.
  • The browser.OpenURL error is still discarded, now as an explicit _ =. Reporting it would change project view --web from "prints the project, quietly fails to open a browser" to something that can fail the command, which felt out of scope. Happy to surface it to stderr instead if you'd prefer.

No test: asserting on browser.OpenURL needs an injection seam the command doesn't have, and adding one for this seemed disproportionate. So this one is verified by reading only — I have not run project view --web -f json against a real server.

@NickJosevski
NickJosevski marked this pull request as ready for review September 15, 2026 05:37
@NickJosevski

Copy link
Copy Markdown
Contributor Author

@YuKitsune this is mostly expanding data that's returned, no breaking changes.

NickJosevski and others added 7 commits September 15, 2026 16:59
Both commands returned far less than the REST API does. list and view now
carry the project group, lifecycle, slug, space, disabled state and tenanted
deployment mode, and view additionally carries the process, variable set,
library variable sets, release settings, connectivity policy and templates.

Group and lifecycle IDs resolve to names the way channel list resolves
lifecycles: two GetAll lookups for the whole list rather than one per project,
best-effort, falling back to the ID when a name can't be resolved.

Existing JSON fields keep their names and their presence, so scripts parsing
the current output are unaffected.

Refs #491

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
versionControlBranch used a bare type assertion on
project.PersistenceSettings. IsVersionControlled and PersistenceSettings
are independent fields on the wire, and the SDK leaves PersistenceSettings
as a nil interface when the block is absent, so a project reporting
IsVersionControlled: true without Git-typed settings crashed the command.

Use a comma-ok assertion and fall back to an empty branch. Added a
regression test that reproduces the panic on the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
project list fetched all lifecycles and all project groups before printing,
but the basic mapper only emits the project name, so scripting callers paid
two extra /all round trips for data that was never rendered.

Extract the format resolution PrintArray and PrintResource already do into
output.ResolveOutputFormat, and use it to skip the lookups for basic output.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
pkg/cmd/project/shared.GetLifecycleMap was a byte-for-byte copy of
getLifecycleMap in pkg/cmd/channel/list, so the two would drift as soon as
either grew pagination or error reporting.

Move the lifecycle and project group lookups, plus the ID fallback helper,
into pkg/lookups so channel commands can use them without importing a
project command's shared package, and delete the channel copy.
project/shared keeps TenantedDeploymentMode, which is project specific.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
DisplayName returns "" when both the resolved name and the ID are empty,
which rendered as a bare "Project group: " / "Lifecycle: " line. Skip the
line instead.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
browser.OpenURL lived inside the Basic formatter, so
`octopus project view X --web -f table` and `-f json` printed the URL but
never opened anything. Hoist the flag check into viewRun.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
versionControlBranch returns "" when a project claims to be version
controlled but carries no Git settings, which rendered a bare
"Version control branch: " line. Skip it, as the project group and
lifecycle labels already do.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

@YuKitsune YuKitsune left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetMap methods don't make it clear which is the key and which is the value. I'd like to see it either called out in the name (i.e. GetLifecycleIdToNameMap) or use types instead (i.e. func GetLifecycleMap(octopus *client.Client) map[LifecycleId]LifecycleName {)

There are also some comments in here that are just noise, or could easily go stale. Have left some suggestions, but feel free to take em' or leave em'.

Comment thread pkg/cmd/project/list/list.go Outdated
Comment on lines +60 to +62
// Two lookups for the whole list rather than one per project, and best-effort
// as channel list is: listing still works without access to either. Basic
// output only prints names, so don't pay for the round trips there.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of detail in this comment, and it's not exactly clear what's important about it. I'd suggest simplifying or removing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed in cdae0ec, down to the one thing the code cannot say:

// Basic output only prints names, so don't pay for the lookups there
var lifecycleIdToNameMap, projectGroupIdToNameMap map[string]string

The two lines I dropped were both restating the code — that it is two calls for the whole list rather than one per project, and that the lookups are best-effort. The first is visible from the call site and the second now lives on the lookup functions' own doc comments, which is where you asked for it.

Comment thread pkg/lookups/lookups.go Outdated
Comment on lines +10 to +12
// GetLifecycleMap resolves lifecycle IDs to names for display. Best-effort: a
// failed lookup yields an empty map and callers fall back to the ID.
func GetLifecycleMap(octopus *client.Client) map[string]string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// GetLifecycleMap resolves lifecycle IDs to names for display. Best-effort: a
// failed lookup yields an empty map and callers fall back to the ID.
func GetLifecycleMap(octopus *client.Client) map[string]string {
// GetLifecycleIdToNameMap resolves lifecycle IDs to names for display.
// If the name cannot be resolved, the caller should fall back to the ID.
func GetLifecycleIdToNameMap(octopus *client.Client) map[string]string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 4b84883, along with the other three doc comments and the local it is assigned to.

Comment thread pkg/lookups/lookups.go Outdated
Comment on lines +24 to +26
// GetProjectGroupMap resolves project group IDs to names for display. Best-effort,
// as GetLifecycleMap is.
func GetProjectGroupMap(octopus *client.Client) map[string]string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// GetProjectGroupMap resolves project group IDs to names for display. Best-effort,
// as GetLifecycleMap is.
func GetProjectGroupMap(octopus *client.Client) map[string]string {
// GetProjectGroupIdToNameMap resolves project group IDs to names for display.
// If the name cannot be resolved, the caller should fall back to the ID.
func GetProjectGroupIdToNameMap(octopus *client.Client) map[string]string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 4b84883.

Comment thread pkg/lookups/lookups.go Outdated
Comment on lines +38 to +40
// GetLifecycleName resolves a single lifecycle ID, which is cheaper than a whole
// map when only one resource is being displayed. Empty when it can't be resolved.
func GetLifecycleName(octopus *client.Client, lifecycleID string) string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// GetLifecycleName resolves a single lifecycle ID, which is cheaper than a whole
// map when only one resource is being displayed. Empty when it can't be resolved.
func GetLifecycleName(octopus *client.Client, lifecycleID string) string {
// GetLifecycleName resolves a single lifecycle name given its ID.
// An empty string is returned when the name cannot be resolved.
func GetLifecycleName(octopus *client.Client, lifecycleID string) string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 4b84883.

Comment thread pkg/lookups/lookups.go Outdated
Comment on lines +51 to +52
// GetProjectGroupName resolves a single project group ID, as GetLifecycleName does.
func GetProjectGroupName(octopus *client.Client, projectGroupID string) string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// GetProjectGroupName resolves a single project group ID, as GetLifecycleName does.
func GetProjectGroupName(octopus *client.Client, projectGroupID string) string {
// GetProjectGroupName resolves a single project group name given its ID.
// An empty string is returned when the name cannot be resolved.
func GetProjectGroupName(octopus *client.Client, projectGroupID string) string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 4b84883.

Comment thread pkg/cmd/project/list/list.go Outdated
// Two lookups for the whole list rather than one per project, and best-effort
// as channel list is: listing still works without access to either. Basic
// output only prints names, so don't pay for the round trips there.
var lifecycleMap, projectGroupMap map[string]string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var lifecycleMap, projectGroupMap map[string]string
var lifecycleIdToNameMap, projectGroupIdToNameMap map[string]string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 4b84883. Also renamed the four read sites in the Json and Table mappers, and pkg/cmd/channel/list picks up the renamed GetLifecycleIdToNameMap — it is the only other caller.

Comment on lines +94 to +95
lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID)
projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID)
projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID)
lifecycleIdToNameName := lookups.GetLifecycleName(opts.Client, project.LifecycleID)
projectGroupIdToNameName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one suggestion I have not taken literally — I think it is a find/replace artifact rather than the name you meant.

These two locals hold a single resolved name, not a map, so lifecycleIdToNameName does not describe them (and GetLifecycleName / GetProjectGroupName keep their names in your other four suggestions, so the call is unchanged either way). I have left them as:

lifecycleName := lookups.GetLifecycleName(opts.Client, project.LifecycleID)
projectGroupName := lookups.GetProjectGroupName(opts.Client, project.ProjectGroupID)

The ID-to-name naming you asked for is applied where there actually is a map — lifecycleIdToNameMap / projectGroupIdToNameMap in project list, in 4b84883. Say the word if there is a different name you had in mind for these two and I will take it.

The comment above them is gone in cdae0ec, per your other note.

Comment thread pkg/cmd/project/view/view.go Outdated
return err
}

// best-effort, as channel list is: viewing still works without access to either

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment isn't valuable.

Suggested change
// best-effort, as channel list is: viewing still works without access to either

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in cdae0ec.

Comment thread pkg/cmd/project/view/view.go Outdated
Comment on lines +214 to +215
// where the project sits and how it releases; skip a label rather than print
// it with nothing after it when neither the name nor the ID is available

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// where the project sits and how it releases; skip a label rather than print
// it with nothing after it when neither the name nor the ID is available
// Skip a label rather than print it with nothing after it when neither the name nor the ID is available

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in cdae0ec, verbatim.

Shortened the sibling comment below it the same way while I was there — it was two lines making the same point about the version control branch label, now one.

NickJosevski and others added 2 commits September 16, 2026 10:07
GetLifecycleMap / GetProjectGroupMap did not say which side of the map
was the ID and which was the name. Rename both to
GetLifecycleIdToNameMap / GetProjectGroupIdToNameMap, along with the
locals they are assigned to, and give all four lookups doc comments that
state what is returned when the name cannot be resolved.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The comment above the project list lookups spent three lines on things
the code already says; keep only the part that explains the branch. Drop
the best-effort note in viewRun, which said nothing the lookups' own doc
comments don't, and shorten the two label-skipping comments in the basic
formatter.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@NickJosevski

Copy link
Copy Markdown
Contributor Author

Thanks — both points actioned, in two commits so they read separately.

Naming (4b84883). GetLifecycleMapGetLifecycleIdToNameMap and GetProjectGroupMapGetProjectGroupIdToNameMap, taking the rename option rather than the typed-map one: map[LifecycleId]LifecycleName would need two new named string types in pkg/lookups that every caller then has to convert at the boundary, which is more ceremony than the two call sites justify. The locals follow the function names, and all four lookups now carry the doc comments you suggested. pkg/cmd/channel/list is the only other caller and picks up the rename.

Comments (cdae0ec). Dropped the best-effort note in viewRun, cut the three-line comment above the project list lookups to the single line explaining the branch, and took your rewording on the label-skipping comment plus its sibling below it. What survives is the two comments that explain a decision rather than the code: why --web moved out of the basic formatter, and why the PersistenceSettings assertion is comma-ok.

One suggestion I did not take literally — the lifecycleIdToNameName rename on view.go:95. Those two hold a single name, not a map, so I think that one was a find/replace artifact; replied inline, happy to be corrected.

go build ./... and go test ./pkg/... are clean. No behaviour change in either commit.

@YuKitsune YuKitsune left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

@NickJosevski
NickJosevski merged commit 846a542 into main Sep 16, 2026
4 checks passed
@NickJosevski
NickJosevski deleted the nj/issue-491 branch September 16, 2026 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Add metadata to octopus project list / view

2 participants